'use client'; import { use, useEffect, useMemo, useState, useRef, useCallback } from 'react'; import { createPortal } from 'react-dom'; import * as signalR from '@microsoft/signalr'; import { Pause, Play, SkipForward, Bell, BellOff, Volume2, Image as ImageIcon, MoreVertical, X, AlertTriangle, Wifi, WifiOff, ChevronUp, ChevronDown } from 'lucide-react'; import { DonationAlertData, DonationRemoteState, AlertQueueItem, AlertStatusKey, RemoteCrewItem, RemoteCrewMembersResponse, RemoteCrewMemberItem } from '@/types/donation'; import { fetchApi } from '@/lib/utils/client'; import './style.scss'; type Props = { params: Promise<{ widgetToken: string }>; }; const STATUS_LABEL: Record = { queued: '대기', playing: '재생 중', delivered: '완료', skipped: '건너뜀', ignored: '무시', failed: '실패' }; type PlayingMeta = { startedAt: number; durationSec: number }; /** * 둥근 시계형 카운트다운 — 보라색 호가 시계방향으로 차오르며 남은 초 표시. * 매 250ms 다시 렌더링 (성능 negligible). */ function TimerRing({ meta }: { meta: PlayingMeta }) { const [now, setNow] = useState(() => Date.now()); useEffect(() => { const id = setInterval(() => setNow(Date.now()), 250); return () => clearInterval(id); }, []); const elapsedSec = (now - meta.startedAt) / 1000; const remainingSec = Math.max(0, meta.durationSec - elapsedSec); const progress = Math.min(1, elapsedSec / meta.durationSec); const r = 12; const c = 2 * Math.PI * r; const offset = c * (1 - progress); return ( {Math.ceil(remainingSec)} ); } type TabKey = 'all'|'queued'|'ignored'|'failed'|'skipped'|'delivered'; const TAB_DEFS: { key: TabKey; label: string }[] = [ { key: 'all', label: '전체' }, { key: 'queued', label: '대기' }, { key: 'ignored', label: '무시' }, { key: 'failed', label: '실패' }, { key: 'skipped', label: '건너뜀' }, { key: 'delivered', label: '완료' } ]; export default function RemotePage({ params }: Props) { const { widgetToken } = use(params); const apiBase = '/api/donation/remote'; const hubUrl = process.env.NEXT_PUBLIC_API_URL + '/hubs/donation'; const [connected, setConnected] = useState(false); const [state, setState] = useState({ isPaused: false, isAccepting: true, isAudioOnly: false, isVideoOnly: false }); const [queue, setQueue] = useState([]); const [openMenuID, setOpenMenuID] = useState(null); const [menuPos, setMenuPos] = useState<{ top: number; right: number }|null>(null); const [activeTab, setActiveTab] = useState('all'); const [isPopup, setIsPopup] = useState(false); const [playingMeta, setPlayingMeta] = useState>({}); // 통합 테스트 후원 form (selectedCrewID null 이면 일반, 값 있으면 크루 후원) const [testName, setTestName] = useState(''); const [testAmount, setTestAmount] = useState(1000); const [testMessage, setTestMessage] = useState(''); const [isTestMode, setIsTestMode] = useState(true); const [testSubmitting, setTestSubmitting] = useState(false); const [crews, setCrews] = useState([]); const [crewMembers, setCrewMembers] = useState([]); const [selectedCrewID, setSelectedCrewID] = useState(null); const [selectedMemberID, setSelectedMemberID] = useState(null); const connectionRef = useRef(null); useEffect(() => { setIsPopup(typeof window !== 'undefined' && !!window.opener); }, []); // 메뉴 외부 클릭 시 닫기 useEffect(() => { if (openMenuID === null) { return; } const handler = (e: MouseEvent) => { const target = e.target as HTMLElement; if (!target.closest('.dpot-remote__menu-dropdown') && !target.closest('.dpot-remote__menu-btn')) { setOpenMenuID(null); setMenuPos(null); } }; document.addEventListener('click', handler); return () => document.removeEventListener('click', handler); }, [openMenuID]); const toggleMenu = (alertID: number, e: React.MouseEvent) => { if (openMenuID === alertID) { setOpenMenuID(null); setMenuPos(null); return; } const rect = e.currentTarget.getBoundingClientRect(); setMenuPos({ top: rect.bottom + 4, right: window.innerWidth - rect.right }); setOpenMenuID(alertID); }; const closeMenu = () => { setOpenMenuID(null); setMenuPos(null); }; const loadState = useCallback(async () => { try { const res = await fetchApi(`${apiBase}/state/${widgetToken}`, { silent: true }); if (res.data) { setState({ isPaused: res.data.isPaused, isAccepting: res.data.isAccepting, isAudioOnly: res.data.isAudioOnly, isVideoOnly: res.data.isVideoOnly }); setQueue(res.data.queue || []); } } catch {} }, [apiBase, widgetToken]); const loadCrews = useCallback(async () => { try { const res = await fetchApi<{ crews: RemoteCrewItem[] }>(`${apiBase}/crews/${widgetToken}`, { silent: true }); if (res.data) { setCrews(res.data.crews); } } catch {} }, [apiBase, widgetToken]); const loadCrewMembers = useCallback(async (crewID: number) => { try { const res = await fetchApi(`${apiBase}/crews/${widgetToken}/${crewID}/members`, { silent: true }); if (res.data) { setCrewMembers(res.data.members); setSelectedMemberID(null); } } catch {} }, [apiBase, widgetToken]); const connectHub = useCallback(() => { const conn = new signalR.HubConnectionBuilder().withUrl(hubUrl).withAutomaticReconnect().build(); conn.on('ReceiveAlert', (data: DonationAlertData) => { setQueue(prev => { const existing = prev.find(q => q.alertID === data.alertID); if (existing) { return prev.map(q => q.alertID === data.alertID ? { ...q, status: 'queued' as AlertStatusKey } : q); } const item: AlertQueueItem = { alertID: data.alertID, donationID: data.donationID, status: 'queued', sponsorMemberID: data.sponsorMemberID, sendName: data.sendName, amount: data.amount, message: data.message, isTest: data.isTest, createdAt: data.createdAt }; return [item, ...prev]; }); }); conn.on('ReceiveState', (s: DonationRemoteState) => setState(s)); conn.on('ReceiveSkip', () => { setQueue(prev => prev.map(q => q.status === 'playing' ? { ...q, status: 'skipped' } : q)); }); conn.on('ReceiveQueueReorder', (data: { swap?: number[] }) => { if (!data?.swap || data.swap.length !== 2) { return; } const [a, b] = data.swap; setQueue(prev => { const idxA = prev.findIndex(q => q.alertID === a); const idxB = prev.findIndex(q => q.alertID === b); if (idxA < 0 || idxB < 0) { return prev; } const next = [...prev]; [next[idxA], next[idxB]] = [next[idxB], next[idxA]]; return next; }); }); conn.on('ReceiveAlertStatusUpdate', (update: { alertID: number; status: AlertStatusKey; displayDurationSec?: number|null; startedAt?: string|null }) => { setQueue(prev => prev.map(q => q.alertID === update.alertID ? { ...q, status: update.status } : q)); // playing 진입 시 타이머 ring 시작용 메타 저장 if (update.status === 'playing' && update.displayDurationSec && update.displayDurationSec > 0) { const started = update.startedAt ? new Date(update.startedAt).getTime() : Date.now(); setPlayingMeta(prev => ({ ...prev, [update.alertID]: { startedAt: started, durationSec: update.displayDurationSec! } })); } else if (update.status !== 'playing') { // playing 종료 → 메타 제거 (memory leak 방지) setPlayingMeta(prev => { if (!(update.alertID in prev)) { return prev; } const next = { ...prev }; delete next[update.alertID]; return next; }); } }); conn.start().then(() => { conn.invoke('JoinChannel', widgetToken); setConnected(true); }).catch(() => {}); conn.onclose(() => setConnected(false)); conn.onreconnected(() => { conn.invoke('JoinChannel', widgetToken); setConnected(true); }); connectionRef.current = conn; }, [hubUrl, widgetToken]); useEffect(() => { loadState(); loadCrews(); connectHub(); return () => { connectionRef.current?.stop(); }; }, [loadState, loadCrews, connectHub]); useEffect(() => { if (selectedCrewID) { loadCrewMembers(selectedCrewID); } else { setCrewMembers([]); setSelectedMemberID(null); } }, [selectedCrewID, loadCrewMembers]); const togglePause = async () => { const next = { ...state, isPaused: !state.isPaused }; setState(next); await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, widgetToken }, silent: true }); }; const toggleAccepting = async () => { const next = { ...state, isAccepting: !state.isAccepting }; setState(next); await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, widgetToken }, silent: true }); }; const toggleAudioOnly = async () => { const next = { ...state, isAudioOnly: !state.isAudioOnly, isVideoOnly: false }; setState(next); await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, widgetToken }, silent: true }); }; const toggleVideoOnly = async () => { const next = { ...state, isVideoOnly: !state.isVideoOnly, isAudioOnly: false }; setState(next); await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, widgetToken }, silent: true }); }; const skipCurrent = async () => { await fetchApi(`${apiBase}/skip/${widgetToken}`, { method: 'POST', silent: true }); }; const ignoreAlert = async (alertID: number) => { await fetchApi(`${apiBase}/ignore/${alertID}`, { method: 'POST', silent: true }); setQueue(prev => prev.map(q => q.alertID === alertID ? { ...q, status: 'ignored' } : q)); closeMenu(); }; // 일시정지 중일 때만, queued 상태 인접 알림과 swap const swapAlert = async (alertID: number, direction: 'up'|'down') => { const queuedItems = filtered.filter(q => q.status === 'queued'); const idx = queuedItems.findIndex(q => q.alertID === alertID); if (idx < 0) { return; } const targetIdx = direction === 'up' ? idx - 1 : idx + 1; if (targetIdx < 0 || targetIdx >= queuedItems.length) { return; } const partner = queuedItems[targetIdx]; await fetchApi(`${apiBase}/reorder`, { method: 'POST', body: { widgetToken, alertIDA: alertID, alertIDB: partner.alertID }, silent: true }); // 백엔드가 broadcast 한 ReceiveQueueReorder 가 도착하면 화면 갱신됨 }; const resendAlert = async (alertID: number) => { await fetchApi(`${apiBase}/resend/${alertID}`, { method: 'POST', silent: true }); setQueue(prev => prev.map(q => q.alertID === alertID ? { ...q, status: 'queued' } : q)); closeMenu(); }; const playNow = async (alertID: number) => { closeMenu(); // status 변경은 SignalR ReceiveAlertStatusUpdate에 맡김 (위젯이 재생 시작 → 'playing' broadcast) // 직접 setQueue 하면 race condition으로 'playing'을 덮어씀 await fetchApi(`${apiBase}/play-now/${alertID}`, { method: 'POST', silent: true }); }; const markDelivered = async (alertID: number) => { closeMenu(); // Playing 상태에서 stuck된 알림 강제 완료 처리 (위젯 종료/네트워크 단절 등) // status 변경은 SignalR broadcast로 동기화 await fetchApi(`${apiBase}/mark-delivered/${alertID}`, { method: 'POST', silent: true }); }; const submitTestDonate = async () => { if (!testName.trim()) { alert('별명을 입력하세요.'); return; } if (testAmount < 1000) { alert('최소 후원 금액은 1,000원입니다.'); return; } if (selectedCrewID && (selectedMemberID === null || selectedMemberID <= 0)) { alert('크루원을 선택하세요.'); return; } if (!isTestMode) { const isCrew = !!selectedCrewID; const msg = isCrew ? '영구 후원으로 기록됩니다. 활성 크루 세션이 있으면 리더보드에 합산됩니다. 계속하시겠습니까?' : '영구 후원으로 기록됩니다. 본인 채널 통계에 반영됩니다. 계속하시겠습니까?'; const ok = window.confirm(msg); if (!ok) { return; } } setTestSubmitting(true); try { await fetchApi(`${apiBase}/test-donate`, { method: 'POST', body: { widgetToken, sendName: testName.trim(), amount: testAmount, message: testMessage.trim() || null, crewMemberID: selectedCrewID ? selectedMemberID : null, isTest: isTestMode }, silent: false }); setTestMessage(''); } catch (e) { console.error('test-donate failed:', e); } finally { setTestSubmitting(false); } }; const counts = useMemo(() => ({ all: queue.length, queued: queue.filter(q => q.status === 'queued' || q.status === 'playing').length, ignored: queue.filter(q => q.status === 'ignored').length, failed: queue.filter(q => q.status === 'failed').length, skipped: queue.filter(q => q.status === 'skipped').length, delivered: queue.filter(q => q.status === 'delivered').length }), [queue]); const filtered = useMemo(() => { if (activeTab === 'all') { return queue; } if (activeTab === 'queued') { return queue.filter(q => q.status === 'queued' || q.status === 'playing'); } return queue.filter(q => q.status === activeTab); }, [activeTab, queue]); const formatTime = (dateStr: string) => { const d = new Date(dateStr); return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`; }; return (
{/* 상단 상태 배너 */}
{state.isPaused ? ( <>
{/* 헤더 */}
DPOT 리모콘
{connected ? {isPopup && ( )}
{/* 운영 토글 (음성만/영상만/후원수신) */}
{/* 통합 테스트 후원 form (크루 미선택=일반, 선택=크루) */}
테스트 후원
setTestName(e.target.value)} maxLength={30} /> 0 ? testAmount : ''} onChange={e => setTestAmount(parseInt(e.target.value || '0', 10))} min={1000} step={1000} />